Skip to content

Commit 10344bc

Browse files
feat: add api request graphql command, UTs, NUTs
1 parent 16c6347 commit 10344bc

File tree

8 files changed

+437
-16
lines changed

8 files changed

+437
-16
lines changed

README.md

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,43 +107,76 @@ sf plugins
107107

108108
<!-- commands -->
109109

110-
- [`sf hello world`](#sf-hello-world)
110+
- [`sf api request graphql`](#sf-api-request-graphql)
111+
- [`sf api request rest ENDPOINT`](#sf-api-request-rest-endpoint)
111112

112-
## `sf hello world`
113+
## `sf api request graphql`
113114

114-
Say hello.
115+
Summary of a command.
115116

116117
```
117118
USAGE
118-
$ sf hello world [--json] [--flags-dir <value>] [-n <value>]
119+
$ sf api request graphql -o <value> --body file [--json] [--flags-dir <value>] [-S Example: report.xlsx | -i]
119120
120121
FLAGS
121-
-n, --name=<value> [default: World] The name of the person you'd like to say hello to.
122+
-S, --stream-to-file=Example: report.xlsx Stream responses to a file.
123+
-i, --include Include the HTTP response status and headers in the output.
124+
-o, --target-org=<value> (required) Username or alias of the target org. Not required if the
125+
`target-org` configuration variable is already set.
126+
--body=file (required) File to use as the body for the request. Specify "-" to read
127+
from standard input.
122128
123129
GLOBAL FLAGS
124130
--flags-dir=<value> Import flag values from a directory.
125131
--json Format output as json.
126132
127133
DESCRIPTION
128-
Say hello.
134+
Summary of a command.
129135
130-
Say hello either to the world or someone you know.
136+
More information about a command. Don't repeat the summary.
131137
132138
EXAMPLES
133-
Say hello to the world:
139+
$ sf api request graphql
140+
```
141+
142+
_See code: [src/commands/api/request/graphql.ts](https://github.com/salesforcecli/plugin-api/blob/v1.0.0/src/commands/api/request/graphql.ts)_
143+
144+
## `sf api request rest ENDPOINT`
145+
146+
Make an authenticated HTTP request to Salesforce REST API and print the response.
147+
148+
```
149+
USAGE
150+
$ sf api request rest ENDPOINT -o username [--flags-dir <value>] [-i | -S Example: report.xlsx] [-X
151+
GET|POST|PUT|PATCH|HEAD|DELETE|OPTIONS|TRACE] [-H key:value...] [--body file]
134152
135-
$ sf hello world
153+
ARGUMENTS
154+
ENDPOINT Salesforce API endpoint
136155
137-
Say hello to someone you know:
156+
FLAGS
157+
-H, --header=key:value... HTTP header in "key:value" format.
158+
-S, --stream-to-file=Example: report.xlsx Stream responses to a file.
159+
-X, --method=<option> [default: GET] HTTP method for the request.
160+
<options: GET|POST|PUT|PATCH|HEAD|DELETE|OPTIONS|TRACE>
161+
-i, --include Include the HTTP response status and headers in the output.
162+
-o, --target-org=username (required) Username or alias of the target org. Not required if the
163+
`target-org` configuration variable is already set.
164+
--body=file File to use as the body for the request. Specify "-" to read from standard
165+
input; specify "" for an empty body.
166+
167+
GLOBAL FLAGS
168+
--flags-dir=<value> Import flag values from a directory.
169+
170+
EXAMPLES
171+
List information about limits in the org with alias "my-org":
138172
139-
$ sf hello world --name Astro
173+
$ sf api request rest 'services/data/v56.0/limits' --target-org my-org
140174
141-
FLAG DESCRIPTIONS
142-
-n, --name=<value> The name of the person you'd like to say hello to.
175+
Get the response in XML format by specifying the "Accept" HTTP header:
143176
144-
This person can be anyone in the world!
177+
$ sf api request rest 'services/data/v56.0/limits' --target-org my-org --header 'Accept: application/xml',
145178
```
146179

147-
_See code: [src/commands/hello/world.ts](https://github.com/salesforcecli/plugin-template-sf/blob/1.1.14/src/commands/hello/world.ts)_
180+
_See code: [src/commands/api/request/rest.ts](https://github.com/salesforcecli/plugin-api/blob/v1.0.0/src/commands/api/request/rest.ts)_
148181

149182
<!-- commandsstop -->

command-snapshot.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
[
2+
{
3+
"alias": [],
4+
"command": "api:request:graphql",
5+
"flagAliases": [],
6+
"flagChars": ["S", "i", "o"],
7+
"flags": ["body", "flags-dir", "include", "json", "stream-to-file", "target-org"],
8+
"plugin": "@salesforce/plugin-api"
9+
},
210
{
311
"alias": [],
412
"command": "api:request:rest",

messages/graphql.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# summary
2+
3+
Summary of a command.
4+
5+
# description
6+
7+
More information about a command. Don't repeat the summary.
8+
9+
# flags.name.summary
10+
11+
Description of a flag.
12+
13+
# flags.name.description
14+
15+
More information about a flag. Don't repeat the summary.
16+
17+
# examples
18+
19+
- <%= config.bin %> <%= command.id %>
20+
21+
# flags.include.summary
22+
23+
Include the HTTP response status and headers in the output.
24+
25+
# flags.header.summary
26+
27+
HTTP header in "key:value" format.
28+
29+
# flags.stream-to-file.summary
30+
31+
Stream responses to a file.
32+
33+
# flags.body.summary
34+
35+
File to use as the body for the request. Specify "-" to read from standard input.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"@salesforce/core": "^8.4.0",
1010
"@salesforce/kit": "^3.2.1",
1111
"@salesforce/sf-plugins-core": "^11.3.2",
12+
"@salesforce/ts-types": "^2.0.12",
1213
"ansis": "^3.3.2",
1314
"got": "^13.0.0",
1415
"proxy-agent": "^6.4.0"

src/commands/api/request/graphql.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
8+
import fs, { createWriteStream } from 'node:fs';
9+
import { EOL } from 'node:os';
10+
import * as os from 'node:os';
11+
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
12+
import { Messages, Org, SfError } from '@salesforce/core';
13+
import { ProxyAgent } from 'proxy-agent';
14+
import ansis from 'ansis';
15+
import got from 'got';
16+
import type { AnyJson } from '@salesforce/ts-types';
17+
18+
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
19+
const messages = Messages.loadMessages('@salesforce/plugin-api', 'graphql');
20+
21+
export default class Graphql extends SfCommand<void> {
22+
public static readonly summary = messages.getMessage('summary');
23+
public static readonly description = messages.getMessage('description');
24+
public static readonly examples = messages.getMessages('examples');
25+
public static readonly state = 'beta';
26+
27+
public static readonly flags = {
28+
'target-org': Flags.requiredOrg(),
29+
'stream-to-file': Flags.string({
30+
summary: messages.getMessage('flags.stream-to-file.summary'),
31+
helpValue: 'Example: report.xlsx',
32+
char: 'S',
33+
exclusive: ['include'],
34+
}),
35+
include: Flags.boolean({
36+
char: 'i',
37+
summary: messages.getMessage('flags.include.summary'),
38+
default: false,
39+
exclusive: ['stream-to-file'],
40+
}),
41+
body: Flags.string({
42+
summary: messages.getMessage('flags.body.summary'),
43+
helpValue: 'file',
44+
required: true,
45+
}),
46+
};
47+
48+
public async run(): Promise<void> {
49+
const { flags } = await this.parse(Graphql);
50+
51+
const org = flags['target-org'];
52+
const streamFile = flags['stream-to-file'];
53+
54+
await org.refreshAuth();
55+
const apiVersion = await org.retrieveMaxApiVersion();
56+
57+
const url = `${org.getField<string>(Org.Fields.INSTANCE_URL)}/services/data/v${apiVersion}/graphql`;
58+
59+
const options = {
60+
agent: { https: new ProxyAgent() },
61+
headers: {
62+
'Content-type': 'application/json',
63+
Authorization: `Bearer ${org.getConnection(apiVersion).getConnectionOptions().accessToken!}`,
64+
...{},
65+
},
66+
body: `{"query":"${fs.readFileSync(flags.body, 'utf8').replaceAll(os.EOL, '\\n')}", "variables": {}}`,
67+
throwHttpErrors: false,
68+
followRedirect: false,
69+
};
70+
71+
if (streamFile) {
72+
const responseStream = got.stream.post(url, options);
73+
const fileStream = createWriteStream(streamFile);
74+
responseStream.pipe(fileStream);
75+
76+
fileStream.on('finish', () => this.log(`File saved to ${streamFile}`));
77+
fileStream.on('error', (error) => {
78+
throw SfError.wrap(error);
79+
});
80+
responseStream.on('error', (error) => {
81+
throw SfError.wrap(error);
82+
});
83+
} else {
84+
const res = await got.post(url, options);
85+
86+
// Print HTTP response status and headers.
87+
if (flags.include) {
88+
let httpInfo = `HTTP/${res.httpVersion} ${res.statusCode} ${EOL}`;
89+
90+
for (const [header] of Object.entries(res.headers)) {
91+
httpInfo += `${ansis.blue.bold(header)}: ${res.headers[header] as string}${EOL}`;
92+
}
93+
this.log(httpInfo);
94+
}
95+
96+
try {
97+
// Try to pretty-print JSON response.
98+
this.styledJSON(JSON.parse(res.body) as AnyJson);
99+
} catch (err) {
100+
// If response body isn't JSON, just print it to stdout.
101+
this.log(res.body);
102+
}
103+
104+
if (res.statusCode >= 400) {
105+
process.exitCode = 1;
106+
}
107+
}
108+
}
109+
}

src/commands/api/request/rest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class Rest extends SfCommand<void> {
6767
}),
6868
};
6969

70-
private static getHeaders(keyValPair: string[]): Headers {
70+
public static getHeaders(keyValPair: string[]): Headers {
7171
const headers: { [key: string]: string } = {};
7272

7373
for (const header of keyValPair) {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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 fs from 'node:fs';
10+
import { config, expect } from 'chai';
11+
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';
12+
13+
config.truncateThreshold = 0;
14+
15+
describe('api:request:graphql NUT', () => {
16+
let testSession: TestSession;
17+
18+
before(async () => {
19+
testSession = await TestSession.create({
20+
scratchOrgs: [
21+
{
22+
config: 'config/project-scratch-def.json',
23+
setDefault: true,
24+
},
25+
],
26+
project: { gitClone: 'https://github.com/trailheadapps/dreamhouse-lwc' },
27+
devhubAuthStrategy: 'AUTO',
28+
});
29+
fs.writeFileSync(
30+
join(testSession.project.dir, 'standard.txt'),
31+
`query accounts {
32+
uiapi {
33+
query {
34+
Account {
35+
edges {
36+
node {
37+
Id
38+
Name {
39+
value
40+
}
41+
}
42+
}
43+
}
44+
}
45+
}
46+
}
47+
`
48+
);
49+
50+
fs.writeFileSync(
51+
join(testSession.project.dir, 'noResults.txt'),
52+
`query Address {
53+
uiapi {
54+
query {
55+
Address {
56+
edges {
57+
node {
58+
Id
59+
}
60+
}
61+
}
62+
}
63+
}
64+
}
65+
`
66+
);
67+
});
68+
69+
after(async () => {
70+
await testSession?.clean();
71+
});
72+
73+
describe('std out', () => {
74+
it('get result in json format', () => {
75+
const result = execCmd('api request graphql --body standard.txt').shellOutput.stdout;
76+
77+
// make sure we got a JSON object back
78+
const parsed = JSON.parse(result) as Record<string, unknown>;
79+
expect(Object.keys(parsed)).to.have.length;
80+
81+
// @ts-expect-error graphql response, just access what we need without typing it
82+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
83+
expect(parsed.data!.uiapi.query.Account.edges.length).to.equal(1);
84+
expect(parsed.errors).to.deep.equal([]);
85+
});
86+
87+
it('get no results correctly', () => {
88+
const result = execCmd('api request graphql --body noResults.txt').shellOutput.stdout;
89+
90+
// make sure we got a JSON object back
91+
const parsed = JSON.parse(result) as Record<string, unknown>;
92+
expect(Object.keys(parsed)).to.have.length;
93+
// @ts-expect-error graphql response, just access what we need without typing it
94+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
95+
expect(parsed.data!.uiapi.query.Address.edges.length).to.equal(0);
96+
expect(parsed.errors).to.deep.equal([]);
97+
});
98+
});
99+
100+
describe('stream-to-file', () => {
101+
it('get result in json format', () => {
102+
execCmd('api request graphql --body standard.txt --stream-to-file out.txt').shellOutput.stdout;
103+
104+
// make sure we got a JSON object back
105+
const parsed = JSON.parse(fs.readFileSync(join(testSession.project.dir, 'out.txt'), 'utf8')) as Record<
106+
string,
107+
unknown
108+
>;
109+
expect(Object.keys(parsed)).to.have.length;
110+
// @ts-expect-error graphql response, just access what we need without typing it
111+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
112+
expect(parsed.data!.uiapi.query.Account.edges.length).to.equal(1);
113+
expect(parsed.errors).to.deep.equal([]);
114+
});
115+
116+
it('get no results correctly', () => {
117+
execCmd('api request graphql --body noResults.txt --stream-to-file empty.txt').shellOutput.stdout;
118+
119+
// make sure we got a JSON object back
120+
const parsed = JSON.parse(fs.readFileSync(join(testSession.project.dir, 'empty.txt'), 'utf8')) as Record<
121+
string,
122+
unknown
123+
>;
124+
expect(Object.keys(parsed)).to.have.length;
125+
// @ts-expect-error graphql response, just access what we need without typing it
126+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
127+
expect(parsed.data!.uiapi.query.Address.edges.length).to.equal(0);
128+
expect(parsed.errors).to.deep.equal([]);
129+
});
130+
});
131+
});

0 commit comments

Comments
 (0)